컨텍스트
본 글은 Golang을 공부하며 주요 내용이라 생각되는 것들을 기록해둔 자료이며, Ubuntu 22.04 LTS 기준으로 작성되었습니다.
Introduction
서버는 각각의 요청에 대해 메타데이터를 핸들링해야 할 필요가 있다. 이 메타데이터는 넓게 보면 두 종류로 분류할 수 있다. 바로 요청을 올바르게 처리하기 위해 필요한 메타데이터와, 요청 처리를 언제 중지해야 할지를 나타내는 메타데이터다.
예를 들면 어느 HTTP 서버에서는 추적 ID를 사용하여, 마이크로서비스를 통해 request chain을 식별하려고 할 수 있다. 또한 시간이 너무 오래 걸릴 수도 있으니, 다른 마이크로서비스의 종료를 요청하는 타이머를 설정할 수도 있다.
대부분의 언어는 threadlocal 변수를 사용하여 이러한 정보들을 저장하고, 특정한 쓰레드에 데이터를 연결한다. 다만 Go의 고루틴은 값을 조회하는데 사용할 수 있는 고유한 ID가 없기 때문에, Go에서는 할 수 없는 방식이다.
Go는 context라는 구성을 통해 이러한 요청 메타데이터 문제를 해결한다. 컨텍스트를 사용하는 방법을 살펴보도록 하자.
What Is the Context?
컨텍스트는 context
패키지에 정의된 Context
인터페이스를 충족시키는 인스턴스일 뿐이다. 달리 추가적인 기능이 아니다.
Go의 이상적인 코드는 함수의 파라미터를 통해 데이터를 명시적으로 전달하는 것을 지향한다.
컨텍스트도 마찬가지로, 함수에 대한 또다른 파라미터일 뿐이다.
Go에서 함수의 마지막 리턴 값을 error
로 하는 것이 국룰인 것처럼,
첫 번째 파라미터를 컨텍스트로 하여 명시적으로 전달하는 것이 또다른 국룰이다.
보통 컨텍스트 파라미터의 변수명은 대개 ctx
로 한다.
func logic(ctx context.Context, info string) (string, error) {
return "", nil
}
context
패키지는 Context
인터페이스를 정의하는것 외에도,
컨텍스트를 만들고 래핑하기 위한 몇 가지 factory function을 포함한다.
CLI 프로그램의 시작 지점처럼 기존 context가 없는 경우, context.Background()
함수를 통해 비어 있는 컨텍스트를 생성한다.
일반적으로 함수에서 concrete type을 반환하는 것과는 달리, 이 함수는 context.Context
타입을 반환한다.
비어 있는 컨텍스트는 시작점이라 할 수 있다.
컨텍스트에 메타데이터를 추가할 때마다, context
패키지의 factory function 중 하나를 사용하여 기존 컨텍스트를 감싸주면 된다.
ctx := context.Background()
result, err := logic(ctx, "a string")
context.TODO()
도 비어 있는context.Context()
인스턴스를 만드는 함수로, 개발 도중 임시적으로 필요한 경우 사용하게끔 의도되었다. 컨텍스트를 어디서 가져와서 어떻게 사용할지 확실하지 않은 경우,context.TODO()
를 사용하면 좋다. 다만 릴리즈할 코드에서는context.TODO()
를 포함하면 안된다.
HTTP 서버를 작성할 때는, 컨텍스트를 획득하고 미들웨어 레이어를 거쳐 http.Handler
까지 전달하는 데 다소 다른 패턴을 사용한다.
컨텍스트는 net/http
패키지가 생긴 지 한참 뒤에 생긴 개념이기에,
호환성 문제로 인해 http.Handler
인터페이스에 context.Context
를 파라미터로 추가하지 못했다.
대신 기존 타입에 새로운 메소드를 추가하였다.
http.Request
에는 컨텍스트에 관련된 두 개의 메소드가 존재한다.
Context()
메소드는 해당 요청과 관련된context.Context
인스턴스를 리턴한다.WithContext()
메소드는context.Context
인스턴스를 파라미터로 받아서, 해당 컨텍스트와 기존의 상태가 결합된 새로운http.Request
인스턴스를 반환한다.
아래 코드는 일반적인 패턴이다.
func Middleware(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// wrap the context with stuff
r = r.WithContext(ctx)
handler.ServeHTTP(w, r)
})
}
미들웨어에서 첫 번째로 하는 일은 Context()
메소드를 사용해, 요청으로부터 기존 컨텍스트를 추출해내는 것이다.
컨텍스트에 값을 추가하고 나면, WithContext()
메소드를 사용해 기존 요청과 값이 추가된 컨텍스트를 기반으로 새로운 컨텍스트를 만들어낸다.
마지막으로 handler
를 호출하여 기존의 http.ResponseWriter
와 새로운 *http.Request
를 보낸다.
handler도 마찬가지로, Context
메소드를 사용하여 요청으로부터 컨텍스트를 추출하고,
앞서 본 것처럼 컨텍스트를 첫 번째 파라미터로 보내면서 비즈니스 로직을 호출한다.
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
err := r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
data := r.FormValue("data")
result, err := logic(ctx, data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
w.Write([]byte(result))
}
WithContext()
메소드를 사용하는 경우가 한 가지 더 있다.
바로 어플리케이션에서 다른 HTTP 서비스로 HTTP 호출을 하는 경우이다.
컨텍스트를 미들웨어로 보낼 때처럼, WithContext()
를 사용해 나가는 요청의 컨텍스트를 설정해야 한다.
func (sc ServiceCaller) callAnotherService(ctx context.Context, data string) (string, error) {
req, err := http.NewRequest(http.MethodGet, "http://example.com?data="+data, nil)
if err != nil {
return "", err
}
req = req.WithContext(ctx)
resp, err := sc.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code %d", resp.StatusCode)
}
// processing response
id, err := processResponse(resp.Body)
return id, err
}
자동완성에 뜨길래 본건데, http.NewRequest()
를 이렇게 쓸 바에는
http.NewRequestWithContext()
를 쓰면 일반적인 패턴대로 컨텍스트를 첫 번째 파라미터로 넘길 수 있는 것 같다.
아무튼 이제 컨텍스트를 획득하고 보내는 방법을 알게 되었으니, Cancellation을 통해 더 유용하게 사용하는 방법을 알아보자.
Cancellation
여러 개의 고루틴을 생성하여, 서로 다른 HTTP 서비스를 호출하는 요청이 있다고 가정해보자. 만약 서비스 하나에서 에러가 발생하여 더 이상 유효한 값을 얻을 수 없다고 한다면, 다른 고루틴을 계속 돌릴 이유가 없어진다. 이러한 맥락에서 Cancellation을 사용하며, 컨텍스트에서 이를 사용하는 방법에 대해 알아볼 것이다.
cancellable한 컨텍스트를 만들기 위해서는 context.WithCancel()
함수를 사용한다.
이 함수는 context.Context
를 파라미터로 받아서, context.Context
와 context.CancelFunc()
를 리턴한다.
이 때 반환받은 context.Context
는 파라미터로 보낸 것과는 다른 컨텍스트이다.
파라미터로 보낸 context.Context
는 parent 컨텍스트가 되며, 반환받은 context.Context
는 child 컨텍스트이다.
또한 child 컨텍스트는 parent 컨텍스트를 감싼(wrap)다.
context.CancelFunc()
함수는 말 그대로 컨텍스트를 취소(cancel)하며, 취소 여부를 확인하고 있는 모든 코드에 처리를 중단하라는 신호를 보낸다.
기본적으로 컨텍스트는 immutable한 인스턴스로 처리되기 때문에, 정보를 추가하기 위해 컨텍스트 안의 값을 변경하기보단 이를 감싸는 child 컨텍스트를 생성한다. 이런 방식을 통해, 컨텍스트를 코드의 가장 깊은 레이어까지 전달할 수 있다. 반대로, 코드의 깊은 레이어에서 표면적인 레이어쪽으로 정보를 전달하는데 사용되지는 않는다.
실제로 동작하는 예제를 살펴보자.
먼저, go mod init [MODULE_NAME]
으로 모듈을 선언해준다.
그리고 servers.go 파일을 생성하여 아래와 같이 작성한다.
package main
import (
"net/http"
"net/http/httptest"
"time"
)
func slowServer() *httptest.Server {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.Write([]byte("Slow response"))
}))
return s
}
func fastServer() *httptest.Server {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("error") == "true" {
w.Write([]byte("error"))
return
}
w.Write([]byte("ok"))
}))
return s
}
httptest.Server
는 원격 서버와 통신하는 코드에 대해 unit test를 하기에 보다 용이하게 설계된 타입이다.
특히 동일한 프로그램 안에 서버와 클라이언트가 존재할 경우 매우 유용하다.
위 예제에는 서로 다른 각각의 서버를 여는 두 개의 함수가 정의되었다.
하나는 요청이 오면 2초동안 기다렸다가 메시지를 리턴하는 서버이며,
하나는 error
라는 쿼리스트링이 true
로 설정되어 있다면 error
라는 메시지를, 아니면 ok
라는 메시지를 보낸다.
이제 코드의 클라이언트 부분인, client.go 파일을 작성해보자.
package main
import (
"context"
"errors"
"fmt"
"io/ioutil"
"net/http"
"sync"
)
var client = http.Client{}
func callBoth(ctx context.Context, errVal string, slowURL string, fastURL string) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
err := callServer(ctx, "slow", slowURL)
if err != nil {
cancel()
}
}()
go func() {
defer wg.Done()
err := callServer(ctx, "fast", fastURL+"?error="+errVal)
if err != nil {
cancel()
}
}()
wg.Wait()
fmt.Println("done with both")
}
func callServer(ctx context.Context, label string, url string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
fmt.Println(label, "request err:", err)
return err
}
res, err := client.Do(req)
if err != nil {
fmt.Println(label, "response err:", err)
return err
}
data, err := ioutil.ReadAll(res.Body)
if err != nil {
fmt.Println(label, "read err:", err)
return err
}
result := string(data)
if result != "" {
fmt.Println(label, "result:", result)
}
if result == "error" {
fmt.Println("cancelling from", label)
return errors.New("error happened")
}
return nil
}
위 예제의 callBoth()
함수에서는 context.WithCancel()
에 파라미터로 넘어온 컨텍스트를 넘김으로써, cancellable한 컨텍스트와 취소 함수를 생성하며,
이 취소 함수의 이름은 cancel
로 짓는 게 국룰이라고 한다.
기억해야 할 점 중 하나는 cancellable한 컨텍스트를 생성하면 취소 함수를 반드시 호출해주어야 한다는 것이다.
만약 그렇게 하지 않으면 고루틴 또는 메모리에서 리소스 낭비가 발생할 것이다.
취소 함수는 한 번 호출하면 두 번째부터의 호출은 무시되므로, 여러번 호출해도 상관없다.
위 예제에서는 defer
를 사용하여 결과적으로 반드시 호출되게 해주었다.
또한 cancellable한 컨텍스트, 라벨, URL 등을 캡쳐하여 두 개의 고루틴을 설정 및 실행해주었고,
sync.WaitGroup
의 sync()
메소드를 사용하여 고루틴들이 끝날 때까지 대기해주었다.
callServer()
함수는 간단한 클라이언트로, 취소 가능한 컨텍스트를 통해 요청을 생성하여 전송한다.
만약 에러가 발생하거나 서버로부터 받은 문자열 결과값이 error
라면, 에러를 리턴한다.
이제 메인함수를 살펴보자. main.go 파일을 아래와 같이 작성한다.
package main
import (
"context"
"os"
)
func main() {
ss := slowServer()
defer ss.Close()
fs := fastServer()
defer fs.Close()
ctx := context.Background()
callBoth(ctx, os.Args[1], ss.URL, fs.URL)
}
main()
함수에서는 서버를 열고, 생성된 컨텍스트로 클라이언트를 돌려 서버에 요청을 보낸다.
작성된 코드를 빌드하여 실행해보자.
$ go build ./...
위 명령어를 치면 모듈이 빌드되어 실행 파일이 생성된다. 나의 경우 생성된 실행 파일은 context-cancel이었다. 두 가지 경우에 대해, 해당 파일을 실행해보자. 아래 경우는 에러가 발생하지 않았을 때이다.
$ ./context-cancel false
fast result: ok
slow result: Slow response
done with both
아래 경우는 에러가 발생하였을 때이다.
$ ./context-cancel true
fast result: error
cancelling from fast
slow response err: Get "http://127.0.0.1:39207": context canceled
done with both
fastServer
에 대한 요청에서 에러가 발생하였으므로 컨텍스트가 취소된다.
이 때 NewRequestWithContext()
가 실행 중이므로, 컨텍스트의 취소를 감지하여, 고루틴이 멈추게 된 것이다.
만약 위 프로그램을 실행해보았다면, 에러가 발생했음에도 프로그램이 즉각 종료되지 않았다는 점을 알 수 있을 것이다. 이는
slowServer()
가 종료되는 것을 기다리기 때문이다. 만약slowServer()
의 타임아웃을 2초에서 6초로 늘린다면,httptest.Server blocked in Close after 5 seconds, waiting for connections
와 같이 신기한 에러 메세지가 출력되는 것을 확인할 수 있을 것이다.
slowServer()
를 아래와 재작성해주면 컨텍스트의 취소를 제대로 처리하여, 즉시 서버가 종료될 것이다.func slowServer_rewrite() *httptest.Server { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() select { case <-ctx.Done(): fmt.Println("server shut down") return case <-time.After(6 * time.Second): w.Write([]byte("Slow response")) } })) return s }
아마 클라이언트 단의 컨텍스트가 취소되면, 요청에 있는 컨텍스트의
Done
신호가 들어오는 것 같다.
위 예제들의 소스코드는 여기에서 확인해볼 수 있다.
이렇게, 수동으로 컨텍스트를 취소하는 방법에 대해 살펴보았다. 이러한 방식도 유용하지만, 다음 섹선에서는 타임아웃을 통해 취소를 자동화하는 방법에 대해 살펴볼 것이다.
Timers
서버가 하는 일 중 가장 중요한 것 중 하나는 요청들을 관리하는 것이다. 아기 개발자들은 가끔 서버가 가능한 한 많은 요청을 받고, 각 클라이언트에 결과를 반환할 수 있을 때까지 작업을 해야 한다고 생각한다. 하지만 이는 아기 개발자들의 잘못된 생각이다. 응애!
서버는 공유 리소스와 비슷한 성격을 가진다. 각 사용자들은 서버에 가능한 많은 요청을 보내고 결과를 얻고 싶어하며, 이 때 다른 사용자의 요청에 대해서는 별 관심이 없다. 자고로 공유 리소스는 모든 사용자에게 동일한 시간만큼 제공할 수 있도록 관리할 필요성이 있다.
서버는 로드(작업량)를 관리하기 위해, 보통 네 가지 방법을 사용한다.
- 동시에 처리하는 요청의 수 제한
- 대기 큐에서 기다리고 있는 요청의 수 제한
- 요청이 처리되는 시간 제한
- 요청이 사용할 수 있는 리소스의 양 제한(메모리, 디스크 등)
Go에서는 앞의 세 개의 제한을 처리하는 도구를 제공한다. 특히 1번과 2번의 경우 이미 우리가 본 것이다. 1번의 경우 고루틴의 수를 제한하면 서버가 동시에 처리하는 요청의 수가 관리되는 것이며, 2번은 buffered channel의 크기를 조절하면 해결된다.
그리고, 컨텍스트를 통해 요청이 얼마나 오래동안 처리될 수 있는지를 제한할 수 있다. 어플리케이션을 만들 때, 사용자가 불만족스러운 경험을 하기 전에 요청을 완료하려면 얼마나 시간이 걸릴 지에 대해 알고 있어야 한다. 만약 요청을 실행할 수 있는 최대 시간을 알고 있다면, 요청에 컨텍스트를 적용할 수 있다.
시간이 제한된 컨텍스트를 생성하는 방법은 두 가지가 있다.
하나는 context.WithTimeout()
함수를 사용하는 것이다. 이 함수는 기존의 컨텍스트와 타임아웃을 나타내는 time.Duration
인스턴스를 파라미터로 받는다.
그리고 명시된 만큼의 시간이 지나면 자동으로 취소되는 컨텍스트와, 컨텍스트를 바로 취소할 수 있는 취소 함수가 함께 리턴된다.
두 번째 방법은 context.WithDeadline()
함수를 사용하는 것이다.
이 함수도 마찬가지로 기존의 컨텍스트를 받으며, time.Duration
인스턴스 대신 time.Time
인스턴스를 파라미터로 받는다.
context.WithTimeout()
함수처럼 해당 시각이 지나면 컨텍스트는 자동으로 취소되는 컨텍스트와 컨텍스트를 바로 취소할 수 있는 취소 함수가 리턴된다.
만약 context.WithDeadline()
에 과거의 time.Time
시각을 보내면, 이미 취소된 컨텍스트를 리턴한다.
만약 컨텍스트가 자동으로 닫히는 시각을 알아내고 싶다면, context.Context
인스턴스의 Deadline()
메소드를 사용한다.
해당 메소드는 컨텍스트가 닫히는 시각을 나타내는 time.Time
인스턴스와, 타임아웃이 설정되어 있는지 여부를 true
나 false
로 리턴한다.
따라서 comma ok idiom을 적용하면 된다.
적용된 예제가 길어져서, 여기에서 확인하면 될 듯 하다.
요청에 대한 전반적인 타임아웃을 설정할 때, 이 타임아웃을 세분화할 수도 있다.
이를테면 한 서비스에서 다른 서비스를 호출하는 구조가 있을 수 있는데,
네트워크 호출을 실행할 수 있는 시간을 제한하여 나머지 처리 시간이나 다른 네트워크 호출에 사용할 시간을 예약할 수 있다.
앞서 보았던 context.WithTimeout()
이나 context.WithDeadline()
으로 parent 컨텍스트를 감싸는 child 컨텍스트를 생성하여,
각각에 호출에 소요되는 시간을 제어할 수 있는 것이다.
특히 child 컨텍스트의 타임아웃은 parent 컨텍스트의 타임아웃에 종속된다. 이를테면 parent 컨텍스트의 타임아웃이 2초이고 child 컨텍스트의 타임아웃이 3초라고 하면, parent 컨텍스트의 타임아웃인 2초가 지나면 child 컨텍스트도 자동으로 취소된다.
마찬가지로 예제가 좀 길어져서, 여기서 확인하면 된다. 이 코드를 실행해보면 parent 컨텍스트의 타임아웃이 먼저 찾아올 것이고, 그와 동시에 child 컨텍스트도 취소된다.
Handling Context Cancellation in Your Own Code
대부분의 경우 코드 내에서 타임아웃이나 취소에 대해 걱절할 필요는 없다. 일반적인 코드들은 기란이 그렇게 오래 걸리지 않는다. 다만 다른 HTTP 서비스를 호출하거나, 데이터베이스를 호출할 때에는 컨텍스트를 파라미터로 잘 넘겨주어야 한다. 얘네들은 보통 타임아웃이 발생하거나 오류가 발생하여 취소를 할만한 상황이 종종 나오는 편이므로, 컨텍스트를 넘김으로써 취소를 처리해주기 용이하다.
만약 컨텍스트가 취소되면 중단되어야 하는 코드를 작성하는 경우, 동시성을 통해 취소 체크를 할 수 있다(10장에서 이미 살펴보았다).
context.Context
타입은 취소를 관리하는 두 가지 메소드를 포함하고 있다.
Done()
메소드는 struct{}
타입의 채널을 리턴한다. 해당 채널은 취소 함수나 타임아웃이 발생할 때 close된다.
이 때 closed channel에 읽기를 시도하면 반드시 zero value를 리턴한다.
이 때 취소할 수 없는 컨텍스트에서
Done()
메소드를 호출하면nil
을 반환한다. 게다가nil
채널에서 읽기를 시도하면 프로그램이 끝날 때까지 읽기를 시도할 것이기 때문에 멈출 것이다.
context.Context
의 Err()
메소드는 만약 컨텍스트가 아직 활성화되어 있다면 nil
을 반환한다.
반면 만약 컨텍스트가 취소되어 있다면, context.Cancelled
또는 context.DeadlineExceeded
둘 중 하나의 sentinel error를 반환한다.
context.Cancelled
는 컨텍스트를 명시적으로 취소했을 때, context.DeadlineExceeded
는 타임아웃이 발생했을 때 리턴된다.
func longRunningThing(ctx context.Context, data int) (int, error) {
ticker := time.NewTicker(time.Second * 1)
defer ticker.Stop()
ctx, cancel := context.WithTimeout(ctx, time.Duration(data)*time.Second)
defer cancel()
var result int
for {
select {
case <-ticker.C:
result += 1
case <-ctx.Done():
return result, ctx.Err()
}
}
}
func longRunningThingManager(ctx context.Context, data int) (int, error) {
type wrapper struct {
result int
err error
}
ch := make(chan wrapper, 1)
go func() {
result, err := longRunningThing(ctx, data)
ch <- wrapper{result, err}
}()
select {
case data := <-ch:
return data.result, data.err
case <-ctx.Done():
return 0, ctx.Err()
}
}
longRunningThing()
함수는 타임아웃이 발생할 때까지 result
의 값을 누적하는 함수이다.
(원래 타임아웃 자체가 에러 상황을 상정하기 때문에, 실제로 이런 함수를 작성하면 안된다!)
case <-ctx.Done():
로 타임아웃이 발생할 시 함수가 리턴된다.
longRunningThingManager()
함수는 select
문을 통해 longRunningThing()
함수의 값이 반환되기까지 기다리거나,
혹은 컨텍스트가 취소될 때까지 기다린다. 취소된다면 에러와 함께 함수가 리턴된다.
이 때 각각의 select
문의 case <-ctx.Done():
안에, ctx.Err()
가 있음을 확인할 수 있다.
이 메소드가 어떤 출력 결과를 반환하는지에 집중하여 확인해볼 것이다.
이어서 main()
함수를 작성해보자.
func main() {
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
time.AfterFunc(time.Second*3, cancel)
num, err := longRunningThingManager(ctx, 5)
if err != nil {
fmt.Println(err)
}
fmt.Println(num)
}
time.AfterFunc(time.Second*3, cancel)
라인의 유무에 따라 출력이 달라지는 것을 확인할 수 있다.
이 라인을 지운다면 컨텍스트가 타임아웃에 의해 취소되며, 지우지 않는다면 컨텍스트가 cancel
함수에 의헤 취소되기 때문이다.
본 절에서는 컨텍스트를 통한 타임아웃의 명시 및 취소 방법에 대해 알아보았다.
물론 이전에 보았던 time.After()
함수를 통해서도 취소가 가능하지만, 개인적으로는 컨텍스트를 통한 방식이 더 좋아보이는 것 같다.
Values
컨텏트의 또 다른 용도라고 할 수 있다. 컨텍스트는 프로그램의 다른 컴포넌트에 요청에 대한 메타데이터를 전달하는 방법을 제공한다.
물론 파라미터를 통해 명시적으로 데이터를 전달하는 방식을 최우선적으로 고려하는 것이 좋다. Go에서는 명시적인 데이터 전달을 이상적으로 여긴다. 특히 함수가 특정 데이터를 반드시 필요로 한다면, 해당 데이터가 어디서 오는지(보내는지) 명확하게 정의되어야 한다.
하지만 데이터를 명시적으로 전달하지 못할 만한 상황이 존재할 수밖에 없다. 그런 경우는 대부분 HTTP 요청 핸들러와 관련 미들웨어에서 발생한다.
앞서 보았듯 모든 요청 핸들러는 각각 request, response를 파라미터로 받는다. 그런데 미들웨어의 핸들러가 해당 값을 사용할 수 있게 만들려면, context에 값을 저장해주어야 한다. 이러한 경우는 유저의 JWT(JSON Web Token)을 추출하거나, 미들웨어 레이어를 거쳐 핸들러나 비즈니스 로직으로 전달되는 요청별 GUID를 생성하고자 하는 경우 발생할 수 있다.
이러한 경우에 대응하여, context
패키지의 다양한 팩토리 함수 중 값을 저장할 수 있는 컨텍스트를 만드는 함수인 context.WithValue()
가 존재한다.
이 함수는 세 개의 파라미터를 받는다. 파라미터는 각각 parent 컨텍스트와, 값을 저장할 key와, 값이다.
그리고 parent 컨텍스트를 감싸며 key-value 페어가 존재하는 child 파라미터를 리턴한다.
이 때 각각 key와 value의 타입은 interface{}
로 선언되어 있다.
만약 어떤 컨텍스트나 해당 컨텍스트의 아무 parent 컨텍스트 중 값이 저장되어 있는지 확인하려면,
context.Context
의 Value()
메소드를 사용하면 된다.
이 메소드는 Key를 파라미터로 받아서 해당 Key에 대한 값을 리턴한다.
만약 해당 Key에 대한 값이 존재하지 않는다면 nil
이 리턴된다.
타입이 interface{}
이기 때문에, 반환받은 값이 유효한지 확인하려면 comma ok idiom을 사용해주어야 한다.
기본적으로 context chain에 저장된 값을 검색하려면 linear search를 사용할 수밖에 없다. 만약 컨텍스트에 저장된 값이 많지 않다면 성능상의 문제가 크진 않겠지만, 컨텍스트에 값을 너무 많이 저장하게 되면 성능이 뚝뚝 떨어질 것이다. 요청을 처리하기 위해 context chain에 값을 필요 이상으로 많이 집어넣어야 한다면, 리팩토링을 한번 해보는 게 좋을 것이다.
컨텍스트에 저장할 key든 value든 타입이 interface{}
이기 때문에 아무거나 다 넣을 수 있긴 하지만,
map
처럼 key나 value의 타입을 고정해주는 것이 좋다(특히 key는 더더욱! 비교가 가능한 타입으로 고정해주는 것이 좋다).
아래 예제에서는 int
를 기반으로, export되지 않은 타입을 생성해주었다.
type userKey int
만약 key의 타입으로 string이나 다른 public type을 사용한다면, 다른 패키지에서 동일한 키값이 생성될 수 있고, 이는 충돌로 이어질 수 있다. 그렇게 되면 컨텍스트에서 다른 패키지에서 쓴 데이터를 읽는 등, 요상한 문제가 발생하여 디버그가 힘들어지기도 한다.
이렇게 export되지 않는 key의 타입을 선언해주고 나면, export되지 않는 상수를 하나 선언해준다.
const key userKey = 1
위 상수와 타입 모두 export되지 않았기 때문에, 패키지 바깥에서 이 데이터를 확인할 방법이 없다.
따라서 컨텍스트에 겹치는 key에 덮어쓰거나 접근할 방법이 없을 것이며, 충돌도 발생하지 않을 것이다.
또한 만약 컨텍스트에 여러 개의 값을 저장해야 한다면 iota
를 사용할 수도 있을 것이다.
기본적으로 상수의 값이 의미있는 것이 아니라 단지 구분을 하기 위해서만 사용되기 때문에, iota
를 사용하기 매우 적절한 경우라고 할 수 있다.
다음으로는 컨텍스트에 값을 집어넣거나, 값을 읽어오는 API를 작성해줄 것이다.
패키지 외부의 코드에서 컨텍스트의 값을 읽고 써야 할 필요가 있는 경우, 이 함수들을 export한다.
값을 저장하는 컨텍스트를 생성하는 함수의 이름은 ContextWith
로 시작해야 한다.
또한 컨텍스트로부터 값을 읽어와서 리턴하는 함수의 이름은 FromContext
로 끝나야 한다.
컨텍스트에 값을 읽고 쓰는 함수들의 예제를 살펴보도록 하자.
func ContextWithUser(ctx context.Context, user string) context.Context {
return context.WithValue(ctx, key, user)
}
func UserFromContext(ctx context.Context) (string, bool) {
user, ok := ctx.Value(key).(string)
return user, ok
}
사용자 정보를 관리하는 코드를 작성했으니, 사용하는 방법을 알아볼 것이다. 먼저, 아래 예제는 쿠키에서 유저 ID를 추출하는 미들웨어이다.
func extractUser(req *http.Request) (string, error) {
userCookie, err := req.Cookie("user")
if err != nil {
return "", err
}
return userCookie.Value, nil
}
func Middleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := extractUser(r)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
ctx := r.Context()
ctx = ContextWithUser(ctx, user)
r = r.WithContext(ctx)
h.ServeHTTP(w, r)
})
}
해당 미들웨어 내에서는 user
의 쿠키 값을 추출한 후, Context()
메소드를 사용해 요청의 컨텍스트를 얻는다.
그리고 앞서 정의한 ContextWithUser()
함수를 통해 유저 정보가 포함된 컨텍스트를 생성한다.
그리고 기존 *http.Request
에서 WithContext()
메소드를 통해, 새로 생성된 컨텍스트를 포함하는 새로운 *http.Request
인스턴스를 만들어 준다.
마지막으로 http.Handler
의 다음 함수를 호출해준다.
func (c Controller) handleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user, ok := UserFromContext(ctx)
if !ok {
w.WriteHeader(http.StatusInternalServerError)
return
}
data := r.URL.Query().Get("data")
result, err := c.Logic.businessLogic(ctx, user, data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
w.Write([]byte(result))
}
위 예제의 핸들러에서는 Context()
메소드로 요청에 대한 컨텍스트를 획득하여,
해당 컨텍스트로부터 UserFromContext()
함수를 통해 유저 정보를 추출한다. 그리고 비즈니스 로직을 호출한다.
파라미터를 통해 값을 넘기기보단, 이런 방식을 통해 컨텍스트에 값을 저장하는게 더 나을 만한 상황도 존재한다. 앞서 말했던 GUID를 추적하는 경우이다. 이 정보는 비즈니스 로직보단 어플리케이션 관리를 위해 사용된다. 이러한 방식을 사용하면 GUID에 대해 알 필요가 없는 비즈니스 로직이 이 정보를 볼 수 없게 되며, 로그를 작성하거나 다른 서버에 연결할 때 사용할 수 있다.
다음은 컨텍스트가 서비스에서 서비스로 전달될 때, GUID를 포함하여 이 값을 추적하고 로그를 기록하는 예제이다.
package tracker
import (
"context"
"fmt"
"net/http"
"github.com/google/uuid"
)
type guidKey int
const key guidKey = 1
func contextWithGUID(ctx context.Context, guid string) context.Context {
return context.WithValue(ctx, key, guid)
}
func guidFromContext(ctx context.Context) (string, bool) {
g, ok := ctx.Value(key).(string)
return g, ok
}
func Middleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if guid := r.Header.Get("X-GUID"); guid != "" {
ctx = contextWithGUID(ctx, guid)
} else {
ctx = contextWithGUID(ctx, uuid.New().String())
}
r = r.WithContext(ctx)
h.ServeHTTP(w, r)
})
}
type Logger struct{}
func (l Logger) Log(ctx context.Context, message string) {
if guid, ok := guidFromContext(ctx); ok {
message = fmt.Sprintf("GUID: %s - %s", guid, message)
}
// do logging
fmt.Println(message)
}
func Request(r *http.Request) *http.Request {
ctx := r.Context()
if guid, ok := guidFromContext(ctx); ok {
r.Header.Add("X-GUID", guid)
}
return r
}
Middleware()
함수는 요청으로부터 GUID를 추출하거나 새로은 GUID를 만들고, 컨텍스트에 이 값을 저장하여 새로운 컨텍스트를 생성한다.
그리고 다음의 Middleware chain을 호출한다.
Logger
구조체에는 컨텍스트와 문자열을 받아서 제네릭하게 로그를 남기는 메소드가 있다.
만약 컨텍스트에 GUID가 있다면, 로그 메시지의 앞에 추가적으로 출력한다.
Request
함수는 다른 서비스를 호출하기 위해 사용되며, 컨텍스트에 GUID가 있다면 Request header에 GUID가 추가된 *http.Request
인스턴스를 리턴한다.
Interface 포스트에서 보았던 것처럼, 인터페이스를 통해 Dependency Injection을 하는 패턴을 사용해볼 것이다. 이 방법을 통해 비즈니스 로직으로부터 불필요한 정보를 분리할 수 있다.
먼저 Logger
인터페이스를 정의하고, request decorator를 나타내는 함수 타입과, 이에 관련된 비즈니스 로직 구조체를 만들어준다.
type RequestDecorator func(*http.Request) *http.Request
type Logger interface {
Log(context.Context, string)
}
type BusinessLogic struct {
RequestDecorator RequestDecorator
Logger Logger
Remote string
}
이제 비즈니스 로직을 구현해준다.
func (bl BusinessLogic) businessLogic(ctx context.Context, user string, data string) (string, error) {
bl.Logger.Log(ctx, fmt.Sprintf("starting businessLogic for %s with %s", user, data))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, bl.Remote+"?query="+data, nil)
if err != nil {
bl.Logger.Log(ctx, fmt.Sprintf("error making remote request: %v", err))
return "", err
}
req = bl.RequestDecorator(req)
res, err := http.DefaultClient.Do(req)
if err != nil {
bl.Logger.Log(ctx, fmt.Sprintf("error making remote request: %v", err))
return "", err
}
body := res.Body
defer body.Close()
out, err := ioutil.ReadAll(body)
if err != nil {
bl.Logger.Log(ctx, fmt.Sprintf("error making remote request: %v", err))
return "", err
}
return string(out), nil
}
GUID가 Logger
와 request decorator에게 전달되는 과정에서 비즈니스 로직은 이를 인식하지 못하며,
프로그램 로직에 필요한 데이터와 프로그램 관리에 필요한 데이터를 분리할 수 있다.
코드에서 연관성을 확인할 수 있는 유일한 부분은 바로 main()
함수이다.
bl := BusinessLogic{
RequestDecorator: tracker.Request,
Logger: tracker.Logger{},
Remote: "http://www.example.com/query",
}
전체 코드는 여기에서 확인할 수 있다.
References
[
](https://learning.oreilly.com/library/view/learning-go/9781492077206/)[Jon Bodner, 『Learning Go』, O'Reilly Media, Inc.](https://learning.oreilly.com/library/view/learning-go/9781492077206/)